import logging.config
import os
import json
import random
from datetime import datetime, timedelta
import sys
from functools import wraps
from time import sleep, time

from dotenv import load_dotenv
from selenium import webdriver
from selenium.common import (
    NoSuchElementException,
    InvalidSelectorException,
    TimeoutException,
    WebDriverException,
    SessionNotCreatedException,
    InvalidSessionIdException,
    NoSuchWindowException,
    StaleElementReferenceException
)
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait

import ctypes
from ctypes import wintypes
import threading

from typing import NamedTuple
from filelock import FileLock

chromedriver_win = r"D:\chrome\chromedriver-win64\chromedriver.exe"
chrome_win = r"D:\chrome\chrome-win64\chrome.exe"
chromedriver_linux = r"/home/s2fm2/chrome/chromedriver-linux64/chromedriver"
chrome_linux = r"/home/s2fm2/chrome/chrome-linux64/chrome"
# ---------------- 平台检测 -----------------
_IS_WIN = sys.platform.startswith('win')
_IS_LINUX = sys.platform.startswith('linux')
if _IS_WIN:
    import ctypes
    from ctypes import wintypes
elif _IS_LINUX:
    import signal
    import subprocess

if _IS_WIN:
    CHROMEDRIVER_PATH = os.getenv("CHROMEDRIVER_PATH", chromedriver_win)
    CHROME_PATH = os.getenv("CHROME_PATH", chrome_win)

elif _IS_LINUX:
    CHROMEDRIVER_PATH = os.getenv("CHROMEDRIVER_PATH", chromedriver_linux)
    CHROME_PATH = os.getenv("CHROME_PATH", chrome_linux)

# ---------------- Windows 分支 -----------------
if _IS_WIN:
    # 常量
    ES_CONTINUOUS = 0x80000000
    ES_SYSTEM_REQUIRED = 0x00000001

    # 提前把 API 函数指针准备好（线程安全由元类保证）
    _set_thread_state = ctypes.WINFUNCTYPE(wintypes.DWORD, wintypes.DWORD)(
        ('SetThreadExecutionState', ctypes.windll.kernel32)
    )


    def _prevent_sleep() -> None:
        _set_thread_state(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)


    def _allow_sleep() -> None:
        _set_thread_state(ES_CONTINUOUS)

# ---------------- Linux 分支 -----------------
elif _IS_LINUX:
    _inhibit_p = None  # 子进程句柄
    _inhibit_cmd = [
        'systemd-inhibit',
        '--why=Prevent sleep by user script',
        '--mode=block',
        '--what=sleep:idle',
        'cat'
    ]


    def _prevent_sleep() -> None:
        global _inhibit_p
        if _inhibit_p is None or _inhibit_p.poll() is not None:
            _inhibit_p = subprocess.Popen(
                _inhibit_cmd,
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL
            )


    def _allow_sleep() -> None:
        global _inhibit_p
        if _inhibit_p and _inhibit_p.poll() is None:
            _inhibit_p.send_signal(signal.SIGTERM)
            _inhibit_p.wait()
            _inhibit_p = None

# ---------------- 未知平台 -----------------
else:
    raise RuntimeError(f'Unsupported platform: {sys.platform}')


# ---------------- 业务层（与平台无关） -----------------
class _PowerMeta(type):
    """
    确保 Power 类仅被初始化一次（线程安全单例）
    内部使用 threading.Lock 防止并发重复实例化，
    降低跨平台 API 多次加载的开销。
    """
    _lock = threading.Lock()
    _inited = False

    def __call__(cls, *args, **kw):
        with cls._lock:
            if not cls._inited:
                cls._inited = True
        return super().__call__(*args, **kw)


class Power(metaclass=_PowerMeta):
    """跨平台“防休眠”工具箱
    ---------------------
    Windows：调用 SetThreadExecutionState 禁止系统休眠/锁屏
    Linux  ：启动 systemd-inhibit 阻塞 sleep/idle
    均为进程级、立即生效；allow_sleep() 可手动解除。
    单例（_PowerMeta）保证多线程安全。"""

    @staticmethod
    def prevent_sleep() -> None:
        _prevent_sleep()

    @staticmethod
    def allow_sleep() -> None:
        _allow_sleep()


class TimePair(NamedTuple):
    """时长两种表示：秒数用于计算，分:秒用于展示。"""
    sec: float  # 秒
    colon: str  # 分:秒


class InitializeDriverAndOptions:
    """浏览器初始化、参数设置模块"""

    def __init__(self):
        self.logger = self.setup_logger()
        # 在 __init__ 中提前声明 driver 属性（初始化为 None）
        self.driver = None  # 声明为实例属性，避免“外部定义”提示

    def initialize_driver(self):
        """初始化 WebDriver"""
        try:
            load_dotenv()  # 加载环境变量
            chromedriver_path = CHROMEDRIVER_PATH
            chrome_options = self.setup_chrome_options()
            service = Service(executable_path=chromedriver_path)
            self.driver = webdriver.Chrome(service=service, options=chrome_options)
            self.driver.set_page_load_timeout(15)
            self.driver.set_script_timeout(15)

        except Exception as e:
            self.logger.error(f"初始化 WebDriver 时发生错误: {str(e)}")
            raise

    @staticmethod
    def setup_chrome_options():
        """配置 Chrome 选项"""
        chrome_options = Options()

        # 指定 Chrome 浏览器二进制文件路径
        chrome_options.binary_location = CHROME_PATH

        # 禁用 GPU 加速（在某些环境下可能导致渲染问题）
        chrome_options.add_argument('--disable-gpu')

        # 禁用沙盒模式（Linux 环境必须，减少安全防护以运行在容器环境）
        chrome_options.add_argument('--no-sandbox')

        # 隐藏浏览器正受到自动化控制的提示（绕过部分网站的反爬检测）
        chrome_options.add_argument("--disable-blink-features=AutomationControlled")

        # 从启动参数中移除自动化标志（进一步隐藏自动化特征）
        chrome_options.add_experimental_option('excludeSwitches', ['enable-automation'])

        # 禁用浏览器日志输出（避免在控制台显示调试信息）
        chrome_options.add_experimental_option('excludeSwitches', ['enable-logging'])

        # 禁用自动化扩展（减少被识别为爬虫的可能性）
        chrome_options.add_experimental_option('useAutomationExtension', False)

        # 避免在 /dev/shm 分区创建临时文件（防止内存不足错误）
        chrome_options.add_argument('--disable-dev-shm-usage')

        # 禁用所有浏览器扩展（减少资源占用并避免干扰）
        chrome_options.add_argument('--disable-extensions')

        # 禁用浏览器插件（如 Flash 等）
        chrome_options.add_argument('--disable-plugins')

        # 禁用弹窗拦截（确保自动化过程中弹窗不会阻塞操作）
        chrome_options.add_argument('--disable-popup-blocking')

        # 禁用信息栏（如"Chrome正在受到自动测试软件控制"提示）
        chrome_options.add_argument('--disable-infobars')

        # 禁用通知功能（防止通知弹窗干扰自动化）
        chrome_options.add_argument('--disable-notifications')

        # 禁用翻译功能（避免页面自动翻译影响元素定位）
        chrome_options.add_argument('--disable-translate')

        # # 禁用同源策略（允许跨域请求，仅用于测试环境）
        # chrome_options.add_argument('--disable-web-security')
        #
        # # 禁用 XSS 审计（避免误报导致页面加载异常）
        # chrome_options.add_argument('--disable-xss-auditor')

        # 禁用 Viz 合成器（解决某些环境下的渲染问题）
        chrome_options.add_argument('--disable-features=VizDisplayCompositor')

        # 禁用渲染器代码完整性检查（提高兼容性）
        chrome_options.add_argument('--disable-features=RendererCodeIntegrity')

        # # 忽略证书错误（允许访问使用自签名证书的网站）
        # chrome_options.add_argument('--ignore-certificate-errors')

        # 忽略 SSL 错误（同上，用于不安全的测试环境）
        chrome_options.add_argument('--ignore-ssl-errors')

        # 设置页面加载策略为"eager"（仅等待DOM加载完成，不等待资源加载）
        chrome_options.page_load_strategy = 'eager'

        return chrome_options

    @staticmethod
    def setup_logger() -> logging.Logger:
        """配置日志记录器"""
        # 动态生成日志文件名（与当前脚本同名）
        # 调用 HistoryManager 的静态方法获取脚本名（无需实例化）
        script_name = HistoryManager.get_script_name()  # 正确调用静态方法
        log_filename = f"{script_name}.log"

        logging_config = {
            'version': 1,
            'formatters': {
                'default': {
                    'format': '%(asctime)s - %(levelname)s - %(message)s',
                }
            },
            'handlers': {
                'file': {
                    'class': 'logging.handlers.RotatingFileHandler',
                    'filename': log_filename,  # 使用动态生成的文件名
                    'maxBytes': 100 * 1024,
                    'backupCount': 3,
                    'formatter': 'default',
                },
                'console': {
                    'class': 'logging.StreamHandler',
                    'formatter': 'default',
                }
            },
            'root': {
                'level': 'INFO',
                'handlers': ['file', 'console']
            }
        }
        logging.config.dictConfig(logging_config)
        return logging.getLogger(__name__)


class PageUtils:
    """工具模块"""

    def __init__(self, driver, logger):
        self.driver = driver
        self.logger = logger
        self.RESTART_BROWSER_COUNT = 0

    def wait_element(self, condition, locator, timeout=10):
        """统一封装，返回元素或 None"""
        try:
            return WebDriverWait(self.driver, timeout).until(condition(locator))
        except TimeoutException:
            return None

    def wait_elements_until_count(self, by, locator, min_count=1, timeout=15):
        """
        通用等待方法：等待指定定位的元素加载，直到数量≥min_count
        :param by: 定位方式（如 By.XPATH、By.CLASS_NAME 等）
        :param locator: 定位符（如 XPath 表达式、class 名称等）
        :param min_count: 最小元素数量（默认1个）
        :param timeout: 超时时间（默认15秒）
        :return: 符合条件的元素列表，超时返回None
        """

        def _wait_condition(driver):
            """内部条件函数：检查元素数量是否达标"""
            elements = driver.find_elements(by, locator)
            return elements if len(elements) >= min_count else None

        try:
            return WebDriverWait(self.driver, timeout).until(_wait_condition)
        except Exception as e:
            self.logger.error(
                f"等待元素（{by}={locator}，至少{min_count}个）超时：{self.extract_error_message(e)}"
            )
            return None

    def is_element_present(self, by, value, timeout=10):
        return self.wait_element(ec.presence_of_element_located, (by, value), timeout)

    def is_element_visible(self, by, value, timeout=10):
        return self.wait_element(ec.visibility_of_element_located, (by, value), timeout)

    def is_element_clickable(self, by, value, timeout=10):
        return self.wait_element(ec.element_to_be_clickable, (by, value), timeout)

    # --------- 关键字语法糖 ---------
    def has_keyword(self, key_word: str, level: str = "visible", timeout: int = 10) -> bool:
        """
        快速判断页面是否出现指定文本
        level: "present" | "visible" | "clickable"
        """
        locator = (By.XPATH, f'//*[contains(text(),"{key_word}")]')
        mapping = {
            "present": ec.presence_of_element_located,
            "visible": ec.visibility_of_element_located,
            "clickable": ec.element_to_be_clickable,
        }
        return bool(self.wait_element(mapping[level], locator, timeout))

    def wait_page_ready(self, timeout: int = 15) -> None:
        """
        阻塞等待直到页面 document.readyState === 'complete'
        :param timeout: 最大等待秒数
        :raises TimeoutException: 超时仍未 ready
        """
        WebDriverWait(self.driver, timeout).until(
            lambda d: d.execute_script("return document.readyState") == "complete"
        )

    # 改进1：优化retry装饰器，支持动态超时和前置清理函数
    @staticmethod
    def retry(
            max_attempts=3,
            backoff_factor=0.5,
            timeout_sequence=None,  # 动态超时时间序列，如[10,5,3]
            restart_on=(SessionNotCreatedException, InvalidSessionIdException),
            cleanup_func=None  # 重试前的清理函数
    ):
        if timeout_sequence is None:
            timeout_sequence = [10, 5, 3]  # 默认超时序列：逐次缩短
        else:
            # 确保超时序列长度与最大重试次数一致
            assert len(timeout_sequence) == max_attempts, "超时序列长度需与最大重试次数一致"

        def decorator(func):
            @wraps(func)
            def wrapper(instance, *args, **kwargs):
                attempts = 0
                while attempts < max_attempts:
                    attempts += 1
                    current_timeout = timeout_sequence[attempts - 1]  # 当前重试的超时时间
                    try:
                        # 传入当前超时时间给被装饰函数
                        return func(instance, *args, timeout=current_timeout, **kwargs)
                    except (NoSuchElementException, InvalidSelectorException):
                        # 不可恢复的错误（如定位符错误），直接抛出
                        instance.logger.error("元素定位失败（语法或逻辑错误），无法重试")
                        raise
                    except TimeoutException:
                        # 可恢复的临时异常，允许重试
                        instance.logger.warning(
                            f"尝试 {attempts}/{max_attempts}：超时（{current_timeout}秒），准备重试..."
                        )
                    except StaleElementReferenceException:
                        # 可恢复的临时异常，允许重试
                        instance.logger.warning(
                            f"尝试 {attempts}/{max_attempts}：元素已失效，准备重试..."
                        )
                    except (SessionNotCreatedException, InvalidSessionIdException, WebDriverException) as e:
                        # 会话相关错误、浏览器崩溃，重启并重试
                        exception_type = e.__class__.__name__
                        error_msg = instance.extract_error_message(e)
                        if isinstance(e, SessionNotCreatedException):
                            instance.logger.error("会话创建失败，检查驱动与浏览器版本")
                        elif isinstance(e, InvalidSessionIdException):
                            instance.logger.error("会话已失效，重启浏览器")
                        elif isinstance(e, WebDriverException) and "connection refused" in error_msg.lower():
                            instance.logger.error("驱动程序连接失败，尝试重启")

                        # 对指定异常重启浏览器
                        if isinstance(e, restart_on) or (
                                isinstance(e, WebDriverException) and "connection refused" in error_msg.lower()
                        ):
                            instance.restart_browser()
                        # 记录通用警告日志
                        instance.logger.warning(
                            f"Attempt {attempts}/{max_attempts} failed: {exception_type} - {error_msg}")
                    except Exception as e:
                        instance.logger.warning(
                            f"尝试 {attempts}/{max_attempts}：{instance.extract_error_message(e)}，准备重试..."
                        )

                    # 重试前执行清理操作（如刷新页面、处理弹窗等）
                    if cleanup_func and callable(cleanup_func):
                        try:
                            cleanup_func(instance)  # 传入实例，便于操作driver
                        except Exception as ce:
                            instance.logger.warning(f"清理操作失败：{instance.extract_error_message(ce)}")

                    # 退避等待（最后一次尝试后不等待）
                    if attempts < max_attempts:
                        wait_time = backoff_factor * (2 ** (attempts - 1))
                        instance.logger.info(f"等待 {wait_time:.1f} 秒后重试")
                        sleep(wait_time)

                raise Exception(f"达到最大重试次数（{max_attempts}次），操作失败")

            return wrapper

        return decorator

    # 改进2：优化click_item方法，增加元素可见性校验和备选定位符
    @retry(
        max_attempts=3,
        timeout_sequence=[10, 5, 3],  # 超时时间逐次缩短
        cleanup_func=lambda self: self._click_cleanup()  # 重试前的清理函数
    )
    def click_item(self, main_xpath, backup_xpaths=None, timeout=10):
        """
        点击元素（支持主定位符+备选定位符）
        :param main_xpath: 主要定位 XPath
        :param backup_xpaths: 备选 CSS 选择器列表
        :param timeout: 等待超时（秒）
        """
        backup_xpaths = backup_xpaths or []
        locators = [(By.XPATH, main_xpath)] + [(By.CSS_SELECTOR, x) for x in backup_xpaths]

        for by, value in locators:
            try:
                # 1. 等待存在
                element = self.is_element_present(by, value, timeout)
                if not element:
                    continue

                # 2. 滚动（避免被遮挡）
                self.driver.execute_script(
                    "arguments[0].scrollIntoView({block: 'center', behavior: 'smooth'});", element
                )
                sleep(0.5)

                # 3. 等待可点击（确保交互状态）
                element = self.is_element_clickable(by, value, timeout)
                if not element:
                    continue

                # 4. 点击（使用JS点击兜底，避免元素层级遮挡导致的点击失败）
                try:
                    element.click()
                except WebDriverException:
                    self.logger.warning("常规点击失败，尝试 JS 点击")
                    self.driver.execute_script("arguments[0].click();", element)
                return True

            except Exception as e:
                self.logger.warning(
                    f"定位符 {by}={value} 点击失败：{self.extract_error_message(e)}"
                )
                continue
        raise TimeoutException(f"所有定位符均超时（{timeout}秒）")

    def get_element_text(self, locator):
        """
        获取元素文本的封装函数（兼容两种调用方式）
        1. 传入定位元组：get_element_text((By.CLASS_NAME, "current-time"))
        2. 传入XPath字符串：get_element_text("//div[@class='name']")
        """
        # 处理定位元组的情况（如(By.CLASS_NAME, "current-time")）
        if isinstance(locator, tuple) and len(locator) == 2:
            by, value = locator  # 解包元组为by和value
        # 处理传统XPath字符串的情况（保持向后兼容）
        else:
            by = By.XPATH
            value = locator  # locator此时是XPath字符串

        # 调用get_element获取元素，再返回文本
        element = self._get_element(by, value)
        return element.text.strip() if element else ""

    @retry(max_attempts=2)
    def _get_element(self, by, value, timeout=15):
        """通用的元素定位函数"""
        return WebDriverWait(self.driver, timeout).until(
            ec.presence_of_element_located((by, value))
        )

    # 增加重试前的清理函数（处理页面异常状态）
    def _click_cleanup(self):
        """点击重试前的清理操作"""
        try:
            # 1. 检查并关闭可能的弹窗（根据实际页面情况调整XPath）
            popup_close_xpath = "//button[contains(text(), '关闭') or contains(@class, 'close')]"
            if self.is_element_present(By.XPATH, popup_close_xpath, timeout=5):
                self.driver.find_element(By.XPATH, popup_close_xpath).click()
                self.logger.info("清理操作：关闭弹窗")
                sleep(1)

            # 2. 刷新页面（重置页面状态）
            self.driver.refresh()
            self.logger.info("清理操作：刷新页面")
            sleep(2)  # 等待刷新完成

            # 3. 确保页面已加载完成
            self.wait_page_ready(15)
        except Exception as e:
            self.logger.warning(f"清理操作执行异常：{self.extract_error_message(e)}")

    def restart_browser(self):
        """重启浏览器并恢复状态"""
        if self.RESTART_BROWSER_COUNT < 3:
            self.RESTART_BROWSER_COUNT += 1
        else:
            self.driver.quit()
            exit(0)
        try:
            if self.driver:
                self.driver.quit()  # 彻底关闭旧窗口
        except Exception as e:
            self.logger.warning(f"关闭旧浏览器失败：{self.extract_error_message(e)}")
        # 重新初始化
        load_dotenv()  # 加载环境变量
        chromedriver_path = CHROMEDRIVER_PATH
        options_builder = InitializeDriverAndOptions()
        chrome_options = options_builder.setup_chrome_options()  # 实例方法调用
        service = Service(executable_path=chromedriver_path)
        self.driver = webdriver.Chrome(service=service, options=chrome_options)
        self.driver.set_page_load_timeout(15)
        self.driver.set_script_timeout(15)
        self.logger.info("WebDriver 初始化成功。")
        self.driver.get(Locators.HOME_URL)  # 恢复到主页
        sleep(3)  # 等待页面加载

    @staticmethod
    def extract_error_message(exception):
        msg = str(exception)
        # 统一去掉换行符，避免格式干扰
        msg = msg.replace("\r\n", "\n").replace("\r", "\n")

        # 匹配 Stacktrace、Build info、System info 等
        for marker in ["Stacktrace:", "Build info:", "System info:", "Capabilities info:"]:
            idx = msg.find(marker)
            if idx != -1:
                return msg[:idx].strip()

        return msg.strip()


class AudioPlayer:
    """音频播放模块"""

    def __init__(self, driver, logger, page_utils):
        self.driver = driver
        self.logger = logger
        self.page_utils = page_utils  # 接收工具类实例
        # 音频相关定位符...

    def _wait_audio_element(self, timeout=10):
        """等 <audio> 出现且 duration>0，否则返回 None"""

        def _ready(cond):
            # 先判断'audio'存在，再 JS 验  duration>0，确认
            return cond(
                lambda d: (el := d.find_element(By.TAG_NAME, 'audio')) and
                          d.execute_script('return arguments[0].duration > 0;', el)
            )

        return self.page_utils.wait_element(
            _ready, None, timeout
        )

    def _get_audio_element(self, timeout=10):
        """定位 HTML5 标准音频元素（<audio> 标签）"""
        try:
            audio_element = self.page_utils.is_element_present(By.TAG_NAME, "audio", timeout)
            if not audio_element:
                return None

            # 再等待 duration 变为有效值（> 0）
            WebDriverWait(self.driver, timeout).until(
                lambda d: d.execute_script("return arguments[0].duration > 0;", audio_element)
            )
            # 等到了，返回元素，等不到返回None
            return audio_element

        except TimeoutException:
            self.logger.warning("音频元素已找到，但 duration 始终为 0，可能未加载资源")
            return None

    def is_audio_playing(self):
        """判断 HTML5 音频是否正在播放"""
        audio_element = self._get_audio_element()
        if not audio_element:
            return False  # 无有效音频元素，视为未播放

        try:
            # 执行 JS 获取 paused 属性（Selenium 无法直接读取元素的原生属性，需通过 JS）
            is_paused = self.driver.execute_script("return arguments[0].paused;", audio_element)
            # paused=False → 正在播放；paused=True → 暂停/未播放
            playing_status = not is_paused
            self.logger.debug(f"音频正在播放：{playing_status}（paused 属性值：{is_paused}）")
            return playing_status
        except Exception as e:
            self.logger.error(f"获取音频播放状态失败：{self.page_utils.extract_error_message(e)}")
            return False

    def wait_for_audio_complete(self):
        """等待音频播放完成，确保不重复播放"""
        # 记录是否已判定为完成，防止重复处理
        is_completed = False
        # 最大等待时间（总时长 + 5秒缓冲），避免无限循环
        max_wait_seconds = self._get_max_wait_time()

        start_time = time()
        while time() - start_time < max_wait_seconds:
            # 已判定完成则直接退出（防止重复检查）
            if is_completed:
                break

            # 检查是否播放完成
            if self.is_audio_ended():
                self.logger.info("音频播放完成，准备退出循环")
                is_completed = True
                break

            # 检查是否暂停，仅在未完成时尝试恢复
            if not self.is_audio_playing():
                # 额外判断：若已接近尾声（>95%），暂停则视为完成，不恢复
                cur, dur = self.get_current_and_duration()
                current_sec, duration_sec = cur.sec, dur.sec  # 只要秒
                if duration_sec > 0 and current_sec / duration_sec >= 0.95:
                    self.logger.info("音频已接近尾声且暂停，视为播放完成")
                    is_completed = True
                    break

                self.logger.warning("音频已暂停，尝试恢复播放")
                self.resume_audio()

            # 缩短检查间隔，提高响应速度（短音频更敏感）
            sleep(1)

        # 最终确认状态，强制停止可能的重播
        if is_completed:
            self._stop_audio()  # 播放完成后强制停止，防止自动重播
            return True
        else:
            self.logger.error("音频播放超时，未正常完成")
            return False

    def get_current_and_duration(self) -> tuple[TimePair, TimePair]:
        """返回 (current, duration) 各自同时带秒和分:秒格式"""
        """
        # 用法：
        # cur, dur = self._get_current_and_duration()
        # cur_sec, dur_sec = cur.sec, dur.sec  # 只要秒
        # cur_str, dur_str = cur.colon, dur.colon  # 只要文本
        """

        def _sec_to_colon(sec: float) -> str:
            m, s = divmod(int(sec), 60)
            return f"{m}:{s:02d}"

        try:
            el = self._get_audio_element()
            cur = float(self.driver.execute_script("return arguments[0].currentTime;", el))
            dur = float(self.driver.execute_script("return arguments[0].duration;", el))
            return TimePair(cur, _sec_to_colon(cur)), TimePair(dur, _sec_to_colon(dur))
        except Exception as e:
            self.logger.warning(f"获取播放时间失败：{self.page_utils.extract_error_message(e)}")
            return TimePair(0.0, ""), TimePair(0.0, "")

    def _get_max_wait_time(self):
        """计算最大等待时间（总时长 + 5秒缓冲）"""
        cur, dur = self.get_current_and_duration()
        _, dur_sec = cur.sec, dur.sec  # 只要秒
        dur_sec = min(dur_sec, 360)  # 最长播放6分钟
        return max(30, int(dur_sec) + 5)  # 至少等待30秒，防止duration获取失败

    def _stop_audio(self):
        """播放完成后强制停止，防止自动重播"""
        try:
            audio_element = self._get_audio_element()
            if audio_element:
                # 1. 先暂停
                self.driver.execute_script("arguments[0].pause();", audio_element)
                # 2. 用 JS 等待 2 秒（不阻塞线程，也可换成 time.sleep(2)）
                self.driver.execute_script(
                    "return new Promise(resolve => setTimeout(resolve, 2000));"
                )
                # 3. 重置进度并彻底停止
                self.driver.execute_script("arguments[0].pause(); arguments[0].currentTime = 0.0;", audio_element)
                self.logger.info("已强制停止音频并重置播放位置")
        except Exception as e:
            self.logger.warning(f"停止音频失败：{self.page_utils.extract_error_message(e)}")

    # 调整is_audio_ended方法的进度判断阈值
    def is_audio_ended(self):
        """判断 HTML5 音频是否播放完成"""
        audio_element = self._get_audio_element()
        if not audio_element:
            return False

        try:
            # 方案1：优先通过ended属性
            is_ended = self.driver.execute_script("return arguments[0].ended;", audio_element)
            if is_ended:
                self.logger.info("音频已播放完成（ended属性为True）")
                return True

            # 方案2：降低进度阈值到95%，避免最后时刻误判
            current_time = self.driver.execute_script("return arguments[0].currentTime;", audio_element)
            duration = self.driver.execute_script("return arguments[0].duration;", audio_element)
            if duration > 0 and current_time >= duration * 0.95:  # 从99%降至95%
                self.logger.info(f"音频播放进度达标({current_time:.1f}/{duration:.1f})，判定为完成")
                return True

            self.logger.debug(f"音频未播放完成（ended={is_ended}，进度：{current_time:.1f}/{duration:.1f}秒）")
            return False
        except Exception as e:
            self.logger.error(f"判断音频播放完成状态失败：{self.page_utils.extract_error_message(e)}")
            return False

    def wait_audio_loaded(self, timeout=15):
        """等待音频资源加载完成（duration > 0）"""
        audio_element = self._get_audio_element(timeout=timeout)
        if not audio_element:
            return False
        try:
            WebDriverWait(self.driver, timeout).until(
                lambda d: d.execute_script("return arguments[0].duration > 0;", audio_element)
            )
            # "音频资源已加载完成"
            self.logger.info("音频资源已加载完成")
            return True
        except TimeoutException:
            self.logger.error("音频资源加载超时")
            return False

    def resume_audio(self):
        """恢复音频播放（仅在暂停状态时执行）"""
        audio_element = self._get_audio_element()
        if not audio_element:
            self.logger.warning("未找到有效音频元素，无法恢复播放")
            return False

        try:
            # 先检查当前状态（避免重复调用play()）
            is_paused = self.driver.execute_script("return arguments[0].paused;", audio_element)
            if not is_paused:
                self.logger.info("音频已在播放中，无需恢复")
                return True

            # 执行播放操作（部分浏览器可能因自动播放政策失败）
            result = self.driver.execute_script("""
                var audio = arguments[0];
                try {
                    audio.play();
                    return !audio.paused;  // 播放后再次验证状态
                } catch (e) {
                    return false;  // 捕获播放失败异常（如浏览器自动播放限制）
                }
            """, audio_element)

            if result:
                self.logger.info("音频已成功恢复播放")
                return True
            else:
                self.logger.error("恢复播放失败（可能受浏览器自动播放政策限制）")
                # 可选：尝试模拟用户交互后再播放（解决自动播放限制）
                return self._resume_with_interaction(audio_element)

        except Exception as e:
            self.logger.error(f"恢复音频播放时发生异常：{self.page_utils.extract_error_message(e)}")
            return False

    def _resume_with_interaction(self, audio_element):
        """通过模拟用户交互绕过自动播放限制（备用方案）"""
        try:
            self.logger.info("尝试通过用户交互恢复播放...")
            # 模拟点击音频元素（触发用户交互）
            audio_element.click()
            sleep(1)
            # 再次尝试播放
            self.driver.execute_script("arguments[0].play();", audio_element)
            # 验证结果
            is_playing = not self.driver.execute_script("return arguments[0].paused;", audio_element)
            if is_playing:
                self.logger.info("通过用户交互成功恢复播放")
                return True
            else:
                self.logger.warning("用户交互后仍无法恢复播放")
                return False
        except Exception as e:
            self.logger.error(f"用户交互恢复播放失败：{self.page_utils.extract_error_message(e)}")
            return False

    def play_news(self, channel_text: str = "") -> bool:
        """
        1. 点播放按钮
        2. 等音频资源加载
        3. 阻塞到播放完成（内部自动处理暂停恢复）
        4. 返回 True/False 表示是否完整听完
        """
        try:
            # 1. 点播放
            if not self.page_utils.click_item(Locators.NEWS_PLAY_BUTTON_PATH):
                return False

            # 2. 等 duration 出现
            if not self.wait_audio_loaded():
                return False

            # 3. 拿标题（可选，失败也不影响）
            title = self.get_news_title()
            self.logger.info(f"新闻标题：{title or '未知'}")

            # 4. 阻塞到结束
            return self.wait_for_audio_complete()

        except Exception as e:
            self.logger.warning(f"「{channel_text}」新闻播放异常：{self.page_utils.extract_error_message(e)}")
            return False

    def get_news_title(self):
        """获取新闻标题"""
        try:
            news_title = self.page_utils.get_element_text(Locators.NEWS_TITLE_PATH)
            if news_title:
                return news_title.strip().replace(" ", "").replace("\n", "")
        except Exception as e:
            self.logger.info(f"获取新闻标题失败：{self.page_utils.extract_error_message(e)}")
        return None


class VideoPlayer:
    """视频播放模块"""

    def __init__(self, driver, logger, page_utils):
        self.d = driver
        self.log = logger
        self.page_utils = page_utils  # 接收工具类实例

    # -------------------- 公共入口 --------------------
    def play_video(self, max_try=3, timeout=15):
        video_title = self._get_video_set_article_title()
        video_title = video_title.replace("\n", "")
        if video_title:
            self.log.info(f"视频标题：{video_title or '未知'}")
        for attempt in range(1, max_try + 1):
            if attempt > 1:
                self.log.info(f'第 {attempt}/{max_try} 次播放。')
            v = self._wait_video(timeout)
            if v is None:
                self.log.error('未找到 <video> 标签')
                sleep(3)
                continue

            _, dur = self._run_js_time(v)  # 这里已经保证 dur > 0
            if dur <= 0:  # 极少见的真正失败
                sleep(3)
                continue
            dur = min(dur, 360)  # 最长播放6分钟

            # 所有播放状态保证 + 完成等待 统一由 _wait_done 负责
            if self._wait_done(v, dur):
                return True
        return False

    def _wait_done(self, video_el, duration_sec,
                   check_interval=2, end_threshold=0.95,
                   max_pause_times=5):
        """首轮即保证播放，后续循环中持续监控并自动恢复暂停"""
        pause_cnt = 0
        while True:
            paused = self.d.execute_script('return arguments[0].paused;', video_el)
            if paused:
                self.d.execute_script('arguments[0].play();', video_el)
                # self.log.info('已自动恢复播放')

            sleep(check_interval)

            # ended？
            if self.d.execute_script('return arguments[0].ended;', video_el):
                self.log.info('video.ended = true，播放完成')
                return True

            # 判断视频播放是否进度达标
            cur, _ = self._run_js_time(video_el)
            if cur >= duration_sec * end_threshold:
                self.log.info(f'视频播放进度达标({cur:.1f}/{duration_sec:.1f})，判定完成')
                return True

            # 再次暂停检测（人为或网络）
            paused = self.d.execute_script('return arguments[0].paused;', video_el)
            if paused:
                pause_cnt += 1
                if pause_cnt > 1:
                    self.log.info(f'检测到暂停，立即恢复（第 {pause_cnt} 次）')
                if pause_cnt > max_pause_times:
                    self.log.warning('连续恢复失败，放弃等待')
                    return False

                ok = self._force_play_video(video_el, max_click_once=True)
                if not ok:
                    self.log.info('点击按钮后仍无法播放，可能需等待缓冲')
            else:
                pause_cnt = 0

            if self._has_replay_btn():
                return True

    # -------------------- 私有辅助 --------------------
    def _force_play_video(self, video_el, max_click_once=True):
        """
        先直接 play()；若仍 paused，则点一次外层按钮再 play()。
        max_click_once=True 表示本轮只点一次按钮，防止死循环。
        返回是否成功播放（not paused）。
        """
        # 1. 直接 play
        self.d.execute_script('arguments[0].play();', video_el)
        sleep(1)  # 给浏览器一点反应时间
        still_paused = self.d.execute_script('return arguments[0].paused;', video_el)

        if not still_paused:
            return True

        # 2. 需要点击外层按钮
        if max_click_once:
            try:
                outer_btn = self.d.find_element(By.XPATH, Locators.video_btn_xpath)
                outer_btn.click()
                sleep(1)
            except Exception as e:
                self.log.warning(f'点击视频页面播放按钮失败: {self.page_utils.extract_error_message(e)}')

            # 3. 点击后再 play 一次
            self.d.execute_script('arguments[0].play();', video_el)
            sleep(1)
            still_paused = self.d.execute_script('return arguments[0].paused;', video_el)

        return not still_paused

    # -----------------------------------------

    def _wait_video(self, timeout=10):
        """用 PageUtils 统一封装等视频元素"""
        return self.page_utils.wait_element(
            ec.presence_of_element_located,
            (By.TAG_NAME, 'video'),
            timeout
        )

    # ===== JS 一次性拿所有时间 =====
    def _run_js_time(self, video_el, timeout=10):
        """
        阻塞直到 video.duration>0 或超时，然后返回 (current, duration)
        拿不到返回 (-1.0, -1.0)
        """
        try:
            WebDriverWait(self.d, timeout).until(
                lambda d: d.execute_script('return arguments[0].duration > 0;', video_el)
            )
            cur, dur = self.d.execute_script(
                'return [arguments[0].currentTime, arguments[0].duration];', video_el
            )
            return float(cur), float(dur)
        except TimeoutException:
            self.log.warning('video.duration 超时仍无效')
            return -1.0, -1.0

    def _get_video_set_article_title(self):
        """获取视频集标题"""
        news_title = self.page_utils.get_element_text(Locators.video_title_path)
        return news_title.strip().replace(" ", "") if news_title else None

    def _has_replay_btn(self):
        """仅示例：通过 class 判断，实际按页面调整"""
        try:
            self.d.find_element(By.CLASS_NAME, 'replay-btn')
            self.log.info('检测到「重新播放」按钮')
            return True
        except NoSuchElementException:
            return False


class Locators:
    """定位、链接模块"""
    # 登录相关
    LOGIN_IFRAME = '//*[@id="ddlogin-iframe"]'
    QR_CODE_IMG = '//*[@id="app"]/div/div[1]/div/div[1]/div[1]/img'
    # 视频相关
    VIDEO_ELEMENT = '//div/div/video'
    CURRENT_TIME = '//div/span[@class="current-time"]'
    # ... 其他定位符

    # 登陆页
    LOGIN_URL = 'https://pc.xuexi.cn/points/login.html'

    # 主页
    HOME_URL = "https://www.xuexi.cn/"

    # 学分页面
    MY_POINTS_URL = "https://pc.xuexi.cn/points/my-points.html"

    # 重要新闻
    IMPORTANT_NEWS_URL = "https://www.xuexi.cn/98d5ae483720f701144e4dabf99a4a34/5957f69bffab66811b99940516ec8784.html"

    # 学习时评
    LEARNING_REVIEW_URL = "https://www.xuexi.cn/d05cad69216e688d304bb91ef3aac4c6/9a3668c13f6e303932b5e0e100fc248b.html"

    # 重要活动视频专辑学习专题报道
    AUDIO_URL_2 = 'https://www.xuexi.cn/4426aa87b0b64ac671c96379a3a8bd26/db086044562a57b441c24f2af1c8e101.html#1novbsbi47k-5'

    # 学习专题报道
    AUDIO_URL_1 = 'https://www.xuexi.cn/4426aa87b0b64ac671c96379a3a8bd26/db086044562a57b441c24f2af1c8e101.html#1koo357ronk-5'

    # 定位所有 class="my-points-card-text" 的积分元素（共4处，按顺序对应不同积分项）
    GET_POINTS_CONTAINER = '//div/div[@class="my-points-card-text"]'

    # 播放新闻开关
    NEWS_PLAY_BUTTON_PATH = '//div/button[@class="voice-lang-switch"]'

    # 新闻标题
    NEWS_TITLE_PATH = '//div[@class="render-detail-title"]'

    # 新闻总时长
    NEWS_DURATION_PATH = '//div/span[contains(@class, "y-audio-total")]'

    # 新闻已播时长
    NEWS_PLAYBACK_TIME_PATH = '//div/span[contains(@class, "y-audio-current")]'
    AUDIO_ELEMENT_PATH = '//div/div[@class="text-wrap"]'
    VIDEO_ELEMENT_PATH = '//div/span[@class="lazyload-wrapper"]'

    video_btn_xpath = '//div[@class="outter"]'
    video_title_path = '//*[@class="videoSet-article-title"]'


class HistoryManager:
    """历史记录管理类，封装与播放历史相关的操作"""

    @staticmethod
    def get_script_name():
        """获取当前脚本的文件名（不含扩展名），兼容无argv的情况"""
        try:
            return os.path.splitext(os.path.basename(sys.argv[0]))[0]
        except IndexError:
            return "xuexi_automation"  # 默认名称

    def __init__(self, script_name=None, days_to_keep=3):
        """
        初始化历史记录管理器
        :param script_name: 脚本名称（用于生成历史文件名），不指定则自动获取
        :param days_to_keep: 保留历史记录的天数
        """
        self.script_name = script_name or self.get_script_name()
        self.days_to_keep = days_to_keep
        self.history_file = f"{self.script_name}_history.json"
        self.lock = FileLock(f"{self.history_file}.lock")  # 锁文件

    def _load_history(self):
        """加载        加载历史记录文件
        :return: 历史记录字典，格式为 {日期: [url列表]}
        """
        with self.lock:  # 读取时加锁
            if not os.path.exists(self.history_file):
                return {}
            try:
                with open(self.history_file, 'r', encoding='utf-8') as f:
                    return json.load(f)
            except json.JSONDecodeError:
                # 处理文件损坏情况
                return {}

    def _save_history(self, history):
        """
        保存历史记录到文件
        :param history: 要保存的历史记录字典
        """
        with self.lock:  # 写入时加锁
            try:
                with open(self.history_file, 'w', encoding='utf-8') as f:
                    json.dump(history, f, ensure_ascii=False, indent=2)
            except IOError as e:
                raise Exception(f"保存历史记录失败: {str(e)}")

    def _cleanup_old_dates(self, history):
        """
        清理超过保留天数的历史记录
        :param history: 原始历史记录字典
        :return: 清理后的历史记录字典
        """
        cutoff = (datetime.now() - timedelta(days=self.days_to_keep)).strftime('%Y-%m-%d')
        return {d: v for d, v in history.items() if d >= cutoff}

    def already_played(self, url):
        """
        检查URL今天是否已播放过
        :param url: 要检查的视频/文章URL
        :return: 已播放返回True，否则返回False
        """
        history = self._cleanup_old_dates(self._load_history())
        today = datetime.now().strftime('%Y-%m-%d')
        return url in history.get(today, [])

    def mark_played_today(self, url):
        """
        标记URL为今天已播放
        :param url: 要标记的视频/文章URL
        """
        history = self._cleanup_old_dates(self._load_history())
        today = datetime.now().strftime('%Y-%m-%d')
        history.setdefault(today, [])
        if url not in history[today]:
            history[today].append(url)
            self._save_history(history)


class WaitingLogin:
    """登录模块，扫描二维码登录"""

    def __init__(self, driver, logger, page_utils):
        self.driver = driver
        self.logger = logger
        self.page_utils = page_utils  # 接收工具类实例
        self.qr_code_scan_waiting_time = 120

    def wait_page_open(self):
        """等候页面打开"""
        # 1. 先等 readyState（最快）
        self.page_utils.wait_page_ready(15)
        # 2. 再等 <body> 里任意可见元素（证明浏览器已开始绘制）
        WebDriverWait(self.driver, 10).until(
            ec.presence_of_element_located((By.TAG_NAME, "body"))
        )

    def waiting_qr_code_ready(self):
        """等待二维码加载就绪"""
        try:
            # 1. 等待登录iframe加载并切换进入
            login_iframe = WebDriverWait(self.driver, 30).until(
                ec.presence_of_element_located((By.XPATH, Locators.LOGIN_IFRAME))
            )

            # 2. 等待二维码元素加载完成
            self.driver.switch_to.frame(login_iframe)  # 切换到所在的iframe
            self.logger.info("已切换到登录iframe")

            qr_img = WebDriverWait(self.driver, 30).until(
                ec.presence_of_element_located((By.XPATH, Locators.QR_CODE_IMG))
            )

            # 3. 验证图片是否完全加载
            WebDriverWait(self.driver, 30).until(
                lambda d: d.execute_script(
                    "return arguments[0].complete && arguments[0].naturalWidth > 0",
                    qr_img
                )
            )
            self.logger.info("二维码已在iframe中加载完成，等待扫描...")
            return login_iframe, qr_img

        except Exception as e:
            self.logger.error(f"iframe或二维码加载失败: {self.page_utils.extract_error_message(e)}")
            return False, False

    def scroll_to_the_center_of_the_window(self, qr_img):
        """ 滚动二维码到窗口中心"""

        # self.driver.switch_to.frame(login_iframe)

        max_scroll = 3
        for attempt in range(1, max_scroll + 1):
            try:
                # 温和滚动
                self.driver.execute_script(
                    "arguments[0].scrollIntoView({block: 'center', behavior: 'smooth'});",
                    qr_img
                )
                sleep(1)

                # 验证是否已在视口
                is_visible = self.driver.execute_script(
                    "return (function(el){"
                    "  const r = el.getBoundingClientRect();"
                    "  return r.top >= 0 && r.bottom <= window.innerHeight;"
                    "})(arguments[0]);",
                    qr_img
                )

                if is_visible:
                    self.logger.info("二维码已滚动到可视区域")
                    break  # 关键：成功就退出整个循环

                # 温和未到位 → 渐进再滚一点
                offset = 100 * attempt
                self.driver.execute_script(
                    f"arguments[0].scrollIntoView({{block: 'start', behavior: 'instant'}});"
                    f"window.scrollBy(0, -{offset});",
                    qr_img
                )
                sleep(1)

            except Exception as e:
                self.logger.warning(f"第 {attempt} 次滚动失败: {self.page_utils.extract_error_message(e)}")

        else:
            # for 循环正常结束（即温和滚动始终未到位）才执行兜底
            self.logger.warning("温和滚动未成功，强制回到顶部再试")
            self.driver.execute_script("window.scrollTo(0, 0);")
            sleep(1)

            self.driver.execute_script(
                "arguments[0].scrollIntoView({block: 'center', behavior: 'instant'});",
                qr_img
            )
            sleep(1)

            # 最终再验证一次
            is_visible = self.driver.execute_script(
                "return (function(el){"
                "  const r = el.getBoundingClientRect();"
                "  return r.top >= 0 && r.bottom <= window.innerHeight;"
                "})(arguments[0]);",
                qr_img
            )
            if is_visible:
                self.logger.info("二维码已强制滚动到可视区域")
            else:
                self.logger.error("经多次滚动二维码仍未进入可视区域，可能影响扫码")

    def waiting_for_qr_code_login(self):
        """等候扫码，一秒查一次是否已跳转，如果跳转了，即登录成功。"""
        for left in range(self.qr_code_scan_waiting_time, 0, -1):
            print(f"\r等待扫码登录... {left}", end="", flush=True)

            # 切回主文档检查URL
            self.driver.switch_to.default_content()
            if any(path in self.driver.current_url for path in ("points/my-study.html", "points/my-points.html")):
                self.logger.info("登录成功")
                print("\n")
                return True

            # 重新定位iframe并切换（关键修改：避免使用过期的iframe引用）
            try:
                # 重新查找iframe
                login_iframe = self.driver.find_element(By.XPATH, Locators.LOGIN_IFRAME)
                self.driver.switch_to.frame(login_iframe)
            except NoSuchElementException:
                self.logger.warning("登录iframe已消失，可能页面已刷新")

            sleep(1)  # 每秒轮询一次
        return False


class PageRedirection:
    """浏览器页面跳转"""

    def __init__(self, driver, logger, page_utils):
        self._my_points_handle = None
        self.driver = driver
        self.logger = logger
        self.page_utils = page_utils  # 接收工具类实例

    def open_and_ready_my_points(self):
        """统一入口：打开积分页 + 记录句柄"""

        try:
            self.driver.get(Locators.MY_POINTS_URL)
            self.page_utils.wait_page_ready(15)

            self._my_points_handle = self.driver.current_window_handle

            key_word = "每日答题"
            self.page_utils.has_keyword(key_word, "present", 10)

            return True

        except Exception as e:
            self.logger.info(f"积分页面不能就绪。 {self.page_utils.extract_error_message(e)}")

            return False

    # 只读属性：外部只能读，不能写
    @property
    def points_handle(self):
        if self._my_points_handle is None:
            raise RuntimeError("请先调用 open_and_ready_my_points()")
        return self._my_points_handle

    def keep_and_clean_redundant_tag(self):
        #  关闭所有非目标窗口
        all_handles = self.driver.window_handles
        for handle in all_handles:
            if handle != self._my_points_handle:
                self.driver.switch_to.window(handle)
                self.driver.close()
                sleep(0.5)
        self.driver.switch_to.window(self._my_points_handle)
        if self.driver.current_url != Locators.MY_POINTS_URL:
            if not self.open_and_ready_my_points():
                self.logger.info("积分页面不能正常就绪，尝试重启。")
                self.page_utils.restart_browser()

    def close_window_handles(self):
        """关闭多余窗口，只保留 self.my_points_handle 页面"""
        try:
            #  获取所有窗口句柄
            all_handles = self.driver.window_handles
            if len(all_handles) == 0:
                self.logger.error("发生极端情况：len(self.driver.window_handles)==0 ")
                self.logger.info("尝试重启浏览器...")
                self.page_utils.restart_browser()

            if len(all_handles) == 1:
                # 只有一个窗口，如果不是积分页面，重新打开积分页面。
                if (self.driver.current_window_handle != self._my_points_handle
                        or self.driver.current_url != Locators.MY_POINTS_URL):
                    if not self.open_and_ready_my_points():
                        self.logger.info("积分页面不能正常就绪，尝试重启浏览器...")
                        self.page_utils.restart_browser()
                    return
                return

            if len(all_handles) > 1:  # 按需切换窗口并清理多余窗口
                if self._my_points_handle and self._my_points_handle in all_handles:
                    self.keep_and_clean_redundant_tag()
                    return

                self.logger.info("意外关闭了我的积分窗口，正在重建它...")
                if not self.open_and_ready_my_points():
                    self.logger.info("尝试重启浏览器...")
                    self.page_utils.restart_browser()
                self.keep_and_clean_redundant_tag()

        except WebDriverException as e:
            self.logger.error(f"关闭窗口失败：{self.page_utils.extract_error_message(e)}")

    def open_url_in_new_tab(self, element_path, new_url):
        """用 Selenium 4 原生 API 打开新标签页"""
        try:
            # 1. 新建空白标签页并切换
            self.driver.switch_to.new_window('tab')  # 'tab' 表示新标签页，'window' 表示新窗口
            # 2. 在新标签页加载 URL
            self.driver.get(new_url)
            # 3. 等待页面加载完成
            self.page_utils.wait_page_ready(15)

            # 再执行等待逻辑
            WebDriverWait(self.driver, 10).until(
                ec.element_to_be_clickable((By.XPATH, element_path))
            )

            self.logger.info(f"原生 API 打开新标签页成功：\n{new_url}")
            return True
        except Exception as e:
            self.logger.error(f"原生 API 打开新标签页失败：{self.page_utils.extract_error_message(e)}")
            return False

    def only_return_to_tag_page(self, tag_handle):
        if self.driver.current_window_handle != tag_handle:
            self.driver.switch_to.window(tag_handle)

    def close_and_return_to_tag_page(self, tag_handle):
        if self.driver.current_window_handle != tag_handle:
            self.driver.close()
            self.driver.switch_to.window(tag_handle)


class AutomationBot:
    """业务模块"""

    def __init__(self):
        self.driver = None
        # 实例化驱动配置类并初始化driver
        self.driver_init = InitializeDriverAndOptions()
        self.driver_init.initialize_driver()  # 执行初始化
        self.driver = self.driver_init.driver  # 获取初始化后的driver
        self.logger = self.driver_init.logger  # 复用已配置的logger

        self.page_utils = PageUtils(self.driver, self.logger)  # 实例化工具类
        self.history_manager = HistoryManager()  # 实例化历史记录管理器
        self.video_player = VideoPlayer(self.driver, self.logger, self.page_utils)  # 传递工具类
        self.audio_player = AudioPlayer(self.driver, self.logger, self.page_utils)
        self.waiting_login = WaitingLogin(self.driver, self.logger, self.page_utils)
        self.page_redirection = PageRedirection(self.driver, self.logger, self.page_utils)

        # 初始化实例变量（替代全局变量）
        # self.my_points_handle = ""
        self.read_completion_count = 0
        self.audio_completion_count = 0

        # 在日志文件开头写入当前时间
        current_time_text = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        self.logger.info(f"============ {current_time_text} ====开始====")
        self.logger.info("WebDriver 初始化成功。")

    def __del__(self):
        """程序退出时自动关闭浏览器（包括KeyboardInterrupt等异常退出场景）"""
        if hasattr(self, 'driver') and self.driver:
            try:
                self.driver.quit()  # 彻底关闭浏览器进程
                self.logger.info("程序退出，浏览器已正常关闭")
            except Exception as e:
                # 避免析构函数中抛异常导致程序崩溃
                print(f"关闭浏览器时出错：{str(e)}")  # 或写入日志文件

    def _wait_login(self):
        # 1. 打开登录页面
        self.driver.get(Locators.LOGIN_URL)
        self.waiting_login.wait_page_open()

        # 2. 检查是否已登录
        self.driver.switch_to.default_content()  # 切回主文档
        if any(path in self.driver.current_url for path in ("points/my-study.html", "points/my-points.html")):
            self.logger.info("已处于登录状态，无需扫码")
            return True

        # 3. 等候二维码
        self.driver.maximize_window()  # 最大化窗口，增加可视区域
        self.logger.info("正在登录...")
        login_iframe, qr_img = self.waiting_login.waiting_qr_code_ready()
        if not qr_img:
            return False

        # 4. 滚动二维码到可视区域（带兜底）
        self.waiting_login.scroll_to_the_center_of_the_window(qr_img)

        # 5. 扫码等待，定期检查主文档的URL变化
        return self.waiting_login.waiting_for_qr_code_login()

    def compare_scores(self):
        """把最新分数写进实例变量（用统一Class+索引定位，简化代码）"""
        # 转入积分页面，判断 MY_POINTS_HANDLE 是否存在、MY_POINTS_HANDLE与URL是否匹配
        points_elements = []

        # 1. 获取所有积分容器（共4处，索引0-3，对应不同积分项）
        try:
            # 切换到目标窗口
            self.driver.switch_to.window(self.page_redirection.points_handle)
            if self.driver.current_url != Locators.MY_POINTS_URL:
                self.page_redirection.open_and_ready_my_points()
            else:
                """强制刷新页面（忽略缓存，比get快，比普通refresh准）"""
                # 方法：用JS执行强制刷新（Ctrl+F5效果）
                self.driver.execute_script("location.reload(true);")
                key_word = "每日答题"
                WebDriverWait(self.driver, 10).until(
                    ec.element_to_be_clickable(
                        (By.XPATH, f'//*[contains(text(),"{key_word}")]')
                    )
                )

            # 等待所有积分元素加载，避免索引越界
            # 等待 XPath 为 Locators.GET_POINTS_CONTAINER 的元素至少4个
            points_elements = self.page_utils.wait_elements_until_count(
                by=By.XPATH,
                locator=Locators.GET_POINTS_CONTAINER,
                min_count=4,
                timeout=15
            )
        except NoSuchWindowException:
            self.page_redirection.open_and_ready_my_points()
        except Exception as e:
            self.logger.error(f"获取积分容器失败：{self.page_utils.extract_error_message(e)}")
            self.read_completion_count = 0
            self.audio_completion_count = 0
            return

        # 2. 提取“我要选读文章”积分（第2个容器，索引1）
        # 确保索引1存在（防止页面结构变化导致元素缺失）
        if len(points_elements) > 1:
            score_txt = points_elements[1].text
        else:
            self.logger.warning("未找到第2个积分容器（文章积分）")
            score_txt = "0分/12分"
        # 处理分数格式（如“6分/12分”→提取6）
        score_txt = score_txt.strip().replace(" ", "").replace('分', '')
        self.read_completion_count = int(score_txt.split('/')[0]) if score_txt else 0
        read_count = int(score_txt.split('/')[1]) if score_txt else 12

        # 3. 提取“我要视听学习”积分（第3个容器，索引2）
        if len(points_elements) > 2:
            score_txt = points_elements[2].text
        else:
            self.logger.warning("未找到第3个积分容器（视听积分）")
            score_txt = "0分/12分"
        score_txt = score_txt.strip().replace(" ", "").replace('分', '')
        self.audio_completion_count = int(score_txt.split('/')[0]) if score_txt else 0
        video_count = int(score_txt.split('/')[1]) if score_txt else 12

        # 4. 输出日志
        self.logger.info(
            f"当前分数：文章 {self.read_completion_count}/{read_count}，视听 {self.audio_completion_count}/{video_count}"
        )

    def study_resource(self, resource_type):
        """
        通用学习方法：处理文章或视听资源（整合read_articles和audio_visuals）
        :param resource_type: 资源类型，可选 "read"（文章）或 "audio"（视听）
        """
        # 1. 校验资源类型，定义差异化参数（核心：将差异点参数化）
        if resource_type == "read":
            task_name = "我要选读文章"
            short_task_name = "文章"
            channels = [("重要新闻", Locators.IMPORTANT_NEWS_URL), ("学习时评", Locators.LEARNING_REVIEW_URL)]
            wrapper_class = "text-wrap"  # 文章资源的容器class
            completion_attr = "read_completion_count"  # 积分属性名
            max_score = 12  # 满分阈值
            element_path = Locators.AUDIO_ELEMENT_PATH  # 文章页面元素路径（按实际调整）
        elif resource_type == "audio":
            task_name = "我要视听学习"
            short_task_name = "视听"
            channels = [("学习专题报道", Locators.AUDIO_URL_1), ("重要活动视频专辑", Locators.AUDIO_URL_2)]
            wrapper_class = "lazyload-wrapper"  # 视听资源的容器class
            completion_attr = "audio_completion_count"  # 积分属性名
            max_score = 12
            element_path = Locators.VIDEO_ELEMENT_PATH  # 视频页面元素路径
        else:
            self.logger.error(f"不支持的资源类型：{resource_type}")
            return

        self.logger.info(f"开始执行「{task_name}」任务")

        # 2. 循环处理每个频道
        for channel_text, channel_url in channels:
            # 切回积分页更新分数，再切回资源列表页
            self.page_redirection.only_return_to_tag_page(self.page_redirection.points_handle)
            self.compare_scores()

            if channel_text == "学习时评" and self.read_completion_count >= 12:
                return

            if channel_text == "重要活动视频专辑" and self.audio_completion_count >= 12:
                return

            print("*" * 23, '-', short_task_name, '-', "*" * 23)  # 分割线
            self.page_redirection.close_window_handles()  # 关闭多余窗口，保留积分页

            # 打开资源列表页面（新标签页）
            if not self.page_redirection.open_url_in_new_tab(element_path, channel_url):
                continue
            origin_handle = self.driver.current_window_handle  # 记录资源列表页句柄

            # 3. 获取资源容器（通用等待逻辑，根据class定位）
            try:
                # 等待 class 为 "lazyload-wrapper" 的元素至少10个
                wrappers = self.page_utils.wait_elements_until_count(
                    by=By.CLASS_NAME,
                    locator=wrapper_class,
                    min_count=10,
                    timeout=15
                )
                if not wrappers:  # 若等待失败，直接处理下一个频道
                    continue
                self.logger.info(f"获取到{len(wrappers)}个「{task_name}」资源")
            except Exception as e:
                self.logger.error(f"获取「{task_name}」资源列表失败：{self.page_utils.extract_error_message(e)}")
                continue

            # 4. 循环处理每个资源（文章/视频）
            for idx, wrapper in enumerate(wrappers, 1):
                print("=" * 23, '-', short_task_name, '-', "=" * 23, '>', str(idx), '<')  # 分割线

                # 切回积分页更新分数，再切回资源列表页
                self.page_redirection.only_return_to_tag_page(self.page_redirection.points_handle)
                self.compare_scores()
                # 检查是否需要继续学习
                if self.read_completion_count >= 12 and self.audio_completion_count >= 12:
                    self.logger.info("文章、视听均已满12分，学习结束")
                    return

                # 检查当前积分是否已达上限，达标则退出
                current_score = getattr(self, completion_attr)
                if current_score >= max_score:
                    self.logger.info(f"「{task_name}」已达满分（{current_score}/{max_score}），停止处理")
                    break

                self.driver.switch_to.window(origin_handle)

                self.logger.info(f"「{task_name}」的第 {idx} 个页面开始处理")
                new_handle = None  # 哨兵：记录新打开的资源页句柄
                try:
                    # 打开资源（点击容器，在新标签页打开）
                    existing_handles = self.driver.window_handles
                    self.driver.execute_script("arguments[0].click();", wrapper)
                    WebDriverWait(self.driver, 10).until(ec.new_window_is_opened(existing_handles))
                    new_handle = [h for h in self.driver.window_handles if h not in existing_handles][0]
                    self.driver.switch_to.window(new_handle)
                    new_url = self.driver.current_url

                    # 过滤已学习的资源
                    if self.history_manager.already_played(new_url):
                        self.logger.warning(f"「{task_name}」的第{idx}个页面已学习，跳过：\n{new_url}")
                        self.page_redirection.close_and_return_to_tag_page(origin_handle)
                        continue

                    # 5. 差异化处理：文章/视频的具体播放逻辑
                    if resource_type == "read":
                        if self.audio_player.play_news(channel_text):
                            self.logger.info(f"第{idx}条新闻播放完成")
                        else:
                            self.logger.warning(f"第{idx}条新闻播放失败")
                            self.page_redirection.close_and_return_to_tag_page(origin_handle)
                            continue
                    else:  # video
                        if self.video_player.play_video():
                            self.logger.info(f"第{idx}个视频播放完成")
                        else:
                            self.logger.warning(f"第{idx}个视频播放失败")
                            self.page_redirection.close_and_return_to_tag_page(origin_handle)
                            continue

                    # 记录已学习
                    self.history_manager.mark_played_today(new_url)
                    self.driver.close()  # 关闭当前资源页
                    self.driver.switch_to.window(origin_handle)  # 切回资源列表页

                except Exception as e:
                    # 异常处理：关闭可能残留的新窗口，确保切回原页面
                    if new_handle and new_handle in self.driver.window_handles:
                        self.driver.close()
                        self.driver.switch_to.window(self.driver.window_handles[-1])
                    if self.driver.current_window_handle != origin_handle:
                        self.driver.switch_to.window(origin_handle)
                    self.logger.error(f"「{task_name}」第{idx}个页面处理失败：{self.page_utils.extract_error_message(e)}")

                # 处理后延迟，模拟人工操作
                sleep(random.uniform(2, 4))

            self.logger.info(f"「{channel_text}」频道处理完成")

    def _check_playback_error(self):
        # 检测"播放错误"提示
        try:
            WebDriverWait(self.driver, 10).until(
                ec.visibility_of_element_located(
                    (By.XPATH, '//*[contains(text(),"播放错误，请稍后再试")]')
                )
            )
            return True
        except TimeoutException:
            return False

    def study_main(self):
        """运行自动化任务"""
        print("正在加载浏览器，如果耗时较长，请耐心等待...")
        print("当出现扫码登录窗口时，请在2分钟内扫码登录。")
        if not self._wait_login():
            self.driver.quit()

        """主学习逻辑：整合后通过调用通用方法处理文章和视听"""
        self.logger.info("开始执行「study_main」任务")

        # 初始化关键句柄my_points_handle
        if not self.page_redirection.open_and_ready_my_points():
            raise RuntimeError("积分页面未就绪，无法继续")
        # 使用句柄
        self.driver.switch_to.window(self.page_redirection.points_handle)

        while True:
            self.page_redirection.close_window_handles()
            self.compare_scores()

            # 检查是否需要继续学习
            need_read = self.read_completion_count < 12
            need_audio = self.audio_completion_count < 12

            if not need_read and not need_audio:
                self.logger.info("文章、视听均已满分，学习结束")
                self.driver.quit()
                exit(0)

            try:
                if need_read:
                    self.study_resource("read")  # 处理文章
                if need_audio:
                    self.study_resource("audio")  # 处理视听
            except Exception as e:
                self.logger.error(f"主循环异常: {self.page_utils.extract_error_message(e)}")
                continue

        # 在日志文件开头写入当前时间
        current_time_text = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        self.logger.info(f"============ {current_time_text} ====结束====")


def main():
    bot = None
    try:
        bot = AutomationBot()
        bot.study_main()  # 执行核心逻辑
    except KeyboardInterrupt:
        print("用户中断程序")
    except Exception as e:
        print(f"程序异常：{str(e)}")
    finally:
        # 手动确保driver关闭，作为__del__的补充
        if bot and hasattr(bot, 'driver') and bot.driver:
            try:
                bot.driver.quit()
                print("finally：浏览器已关闭")
            except Exception as e:
                print(f"finally：关闭浏览器出错：{str(e)}")


if __name__ == "__main__":
    power = Power()
    power.prevent_sleep()
    main()
    power.allow_sleep()
